---
title: Java 并发面试二
date: 2024-07-23 07:21:03
order: 8
categories:
- Java
- JavaCore
- 面试
tags:
- Java
- JavaCore
- 面试
- 并发
permalink: /pages/96684ccf/
---
# Java 并发面试二
## Java 锁
### 【中等】Java 中,根据不同维度划分,锁有哪些分类?⭐⭐⭐
在 Java 中,锁可以按照 **多个维度** 进行分类,不同维度的锁适用于不同的并发场景。以下是详细的分类:
**按锁的公平性划分**
| **锁类型** | **特点** | **实现类/关键字** |
| ------------ | -------------------------------------------------------------------------------- | -------------------------------------- |
| **公平锁** | 严格按照线程请求顺序(FIFO)分配锁,避免线程饥饿,但性能较低。 | `ReentrantLock(true)` |
| **非公平锁** | 允许插队,新请求的线程可能直接抢到锁,吞吐量高,但可能导致线程饥饿(默认方式)。 | `ReentrantLock(false)`、`synchronized` |
**按锁的获取方式划分**
| **锁类型** | **特点** | **实现类/关键字** |
| ---------- | ---------------------------------------------------------- | ------------------------------- |
| **悲观锁** | 认为并发冲突必然发生,先加锁再操作(阻塞其他线程)。 | `synchronized`、`ReentrantLock` |
| **乐观锁** | 认为并发冲突较少,不加锁,更新时检查(CAS 或版本号机制)。 | `AtomicInteger`、`StampedLock` |
**按锁的可重入性划分**
| **锁类型** | **特点** | **实现类/关键字** |
| -------------- | ----------------------------------------------------------------- | ------------------------------- |
| **可重入锁** | 同一线程可多次获取同一把锁(避免死锁)。 | `ReentrantLock`、`synchronized` |
| **不可重入锁** | 同一线程重复获取同一把锁会导致死锁(Java 无原生实现,需自定义)。 | 无(需自行实现) |
**按锁的共享性划分**
| **锁类型** | **特点** | **实现类/关键字** |
| -------------------- | -------------------------------------------------------------------- | ------------------------------- |
| **独占锁(排他锁)** | 同一时间只有一个线程能持有锁(如 `synchronized`、`ReentrantLock`)。 | `synchronized`、`ReentrantLock` |
| **共享锁** | 允许多个线程同时读取,但写入时独占(如 `ReadWriteLock`)。 | `ReentrantReadWriteLock` |
**按锁的阻塞方式划分**
| **锁类型** | **特点** | **实现类/关键字** |
| ---------------- | ------------------------------------------------------------ | ------------------------------- |
| **阻塞锁** | 获取不到锁时,线程进入阻塞状态(如 `synchronized`)。 | `synchronized`、`ReentrantLock` |
| **自旋锁** | 获取不到锁时,线程循环尝试(避免线程切换,但消耗 CPU)。 | `AtomicInteger`(CAS 自旋) |
| **适应性自旋锁** | JVM 自动优化自旋次数(如 `synchronized` 在 JDK 6+ 的优化)。 | JVM 内部优化 |
**按锁的优化策略划分**
| **锁类型** | **特点** | **实现类/关键字** |
| ------------ | ---------------------------------------------------------------- | ------------------------------ |
| **偏向锁** | 单线程访问时无同步开销(JDK 6+ 对 `synchronized` 的优化)。 | JVM 自动优化(`synchronized`) |
| **轻量级锁** | 多线程无竞争时,使用 CAS 代替阻塞(JDK 6+ 优化)。 | JVM 自动优化(`synchronized`) |
| **重量级锁** | 真正的互斥锁,涉及 OS 线程阻塞(如 `synchronized` 竞争激烈时)。 | JVM 自动升级(`synchronized`) |
**按锁的实现方式划分**
| **锁类型** | **特点** | **实现类/关键字** |
| -------------------- | ----------------------------------------- | -------------------------------- |
| **内置锁(JVM 锁)** | 由 JVM 实现(如 `synchronized`)。 | `synchronized` |
| **显式锁** | 由 Java API 提供(如 `ReentrantLock`)。 | `ReentrantLock`、`ReadWriteLock` |
| **分布式锁** | 跨 JVM 的锁(如 Redis、ZooKeeper 实现)。 | `Redisson`、`Curator` |
**总结**
| **分类维度** | **锁类型** |
| ------------ | --------------------------------------------------------------------------- |
| **公平性** | 公平锁、非公平锁 |
| **获取方式** | 悲观锁、乐观锁 |
| **可重入性** | 可重入锁、不可重入锁 |
| **共享性** | 独占锁、共享锁 |
| **阻塞方式** | 阻塞锁、自旋锁、适应性自旋锁 |
| **优化策略** | 偏向锁、轻量级锁、重量级锁 |
| **实现方式** | 内置锁(`synchronized`)、显式锁(`ReentrantLock`)、分布式锁(`Redisson`) |
**选择合适的锁取决于:**
- **并发竞争程度**(高竞争→悲观锁,低竞争→乐观锁)
- **任务执行时间**(长任务→公平锁,短任务→非公平锁)
- **读写比例**(读多→共享锁,写多→独占锁)
- **是否需要跨 JVM**(是→分布式锁)
这些分类帮助开发者根据业务场景选择最优的锁策略,平衡 **性能、公平性、一致性**。
### 【中等】悲观锁和乐观锁有什么区别?⭐⭐⭐⭐
**悲观锁假定会冲突,提前加锁阻塞;乐观锁假定不冲突,提交时检测版本,冲突则重试。**
- **悲观锁**:先加锁再操作,适合写多读少的高并发场景,保证安全但性能较低,如金融交易。
- **乐观锁**:通过版本号或 CAS 机制实现,提交时检查数据是否被修改,适合读多写少的场景,如电商库存。
以下是悲观锁与乐观锁的详细对比:
| **对比维度** | **悲观锁** | **乐观锁** |
| -------------- | ------------------------------------------------------------------ | ------------------------------------------------------- |
| **核心思想** | 假定并发冲突必然发生,先加锁再访问数据 | 假定并发冲突较少,先操作再检测冲突 |
| **锁机制** | 显式加锁(阻塞其他线程) | 无锁机制(依赖 CAS 或版本号控制) |
| **实现方式** | `synchronized`、`ReentrantLock`、数据库`SELECT FOR UPDATE` | `Atomic`类(CAS)、版本号机制、数据库乐观锁(如 MVCC) |
| **线程阻塞** | 会阻塞竞争线程(线程挂起) | 不阻塞线程,但可能自旋重试或失败 |
| **数据一致性** | 强一致性(独占访问) | 最终一致性(可能需重试) |
| **适用场景** | - 写操作频繁
- 临界区代码执行时间长
- 强一致性要求高 | - 读多写少
- 短平快操作
- 高吞吐量需求 |
| **性能特点** | - 高竞争时性能下降明显(线程切换开销)
- 低竞争时仍有固定锁开销 | - 低竞争时性能极佳(无阻塞)
- 高竞争时 CPU 自旋浪费 |
| **冲突处理** | 通过锁排队避免冲突 | 通过重试或放弃处理冲突 |
| **典型应用** | - 银行转账
- 订单支付
- 数据库行级锁 | - 库存扣减
- 计数器
- 点赞系统 |
| **优缺点** | ✔️ 强一致性
❌ 吞吐量低、死锁风险 | ✔️ 高并发性能好
❌ 实现复杂、可能 ABA 问题 |
### 【中等】公平锁和非公平锁有什么区别?⭐⭐⭐
**公平锁按请求顺序分配,非公平锁允许插队,可能让先到的线程等待。**
- **公平锁**:线程获取锁的顺序严格遵循请求的先后顺序,保证公平性但可能降低吞吐量。
- **非公平锁**:允许后请求的线程“插队”抢先获取锁,虽可能造成饥饿但通常能提高系统整体性能。
公平锁和非公平锁的详细对比:
| **对比维度** | **公平锁 (Fair Lock)** | **非公平锁 (Nonfair Lock)** |
| -------------------- | ---------------------------------------------------- | ------------------------------------------ |
| **锁获取顺序** | 严格按照线程请求顺序(FIFO)分配锁 | 允许插队,新请求的线程可能直接抢到锁 |
| **性能表现** | 吞吐量较低(上下文切换频繁) | 吞吐量较高(减少线程切换,但可能线程饥饿) |
| **响应时间** | 等待时间稳定(适合长任务) | 短任务可能更快获取锁(适合高并发短任务) |
| **适用场景** | - 需要严格公平性
- 线程执行时间差异大(避免饥饿) | - 高并发短任务
- 追求吞吐量 |
| **锁实现类** | `ReentrantLock(true)` | `ReentrantLock(false)`(默认) |
| **实现** | 依赖 AQS 维护等待线程,先到先得 | 先尝试 CAS 抢锁,失败后进入 AQS 队列 |
| **线程饥饿** | 不会发生 | 可能发生(高并发时某些线程长期无法获取锁) |
| **操作系统调度影响** | 依赖系统线程调度,可能因优先级反转影响公平性 | 更依赖 JVM 的锁优化策略 |
| **锁重入性** | 支持(与公平性无关) | 支持(与公平性无关) |
| **适用并发模型** | 适合任务执行时间不均衡的场景 | 适合任务执行时间短的场景 |
**如何选择?**
- **选公平锁**:
- 需要严格顺序执行(如订单处理)
- 避免低优先级线程饥饿
- 线程任务执行时间差异大
- **选非公平锁**:
- 追求高吞吐量(如秒杀系统)
- 任务执行时间短且均匀
- 能接受偶尔的线程饥饿
**注意事项:**
- **默认行为**:`ReentrantLock` 和 `synchronized` 默认都是**非公平锁**(因为性能更好)。
- **性能差异**:非公平锁在高并发下吞吐量可提升 **10%~30%**,但可能增加延迟方差。
- **synchronized 的公平性**:Java 的 `synchronized` **不支持公平锁**,仅 `ReentrantLock` 可配置。
### 【困难】AQS 的实现原理是什么?⭐⭐⭐⭐⭐
AQS(**AbstractQueuedSynchronizer**)是 Java 并发包(`java.util.concurrent.locks`)的核心框架,用于构建锁(如 `ReentrantLock`)和同步器(如 `CountDownLatch`、`Semaphore`)。
**AQS 用一个 volatile int 状态值 + 一个双向链表队列(CLH),通过 CAS 自旋实现线程的排队与唤醒,是 Java 并发锁的“骨架引擎”。**
::: info AQS 要点
:::
AQS 原理可归纳为:**2 种模式,3 大核心,4 步操作**
**2 种模式**
- **独占模式(Exclusive)**:同一时刻只有一个线程能获取资源(如 `ReentrantLock`)。
- **共享模式(Shared)**:多个线程可同时获取资源(如 `Semaphore`, `CountDownLatch`)。
**3 大核心**
1. **状态(State)**:一个 `volatile` 整型变量,用于表示同步状态。`state` 在不同的同步组件中意义不同。
- **锁的意义**:0 代表无锁,>0 代表有锁(可重入时累加)。例如在 `ReentrantLock` 里,`state` 为 0 表示锁未被持有,大于 0 表示锁已被持有,且重入次数就是 `state` 的值。
- **信号量/CountDownLatch 等意义**:state 表示可用资源数或倒数计数。
2. **同步队列(CLH 变体)**:一个**双链表**,存放等待获取资源的线程。`Node` 包含以下重要属性:
- **`thread`**:指向等待获取同步状态的线程。
- **`prev` 和 `next`**:分别指向前一个节点和后一个节点,从而形成双向链表。
- **`waitStatus`**:表示节点的等待状态,常见的状态有:
- `CANCELLED`(1):表示该节点对应的线程已取消等待。
- `SIGNAL`(-1):表示该节点的后继节点需要被唤醒。
- `CONDITION`(-2):表示该节点处于条件队列中。
- `PROPAGATE`(-3):用于共享模式下,表明状态需要向后传播。
- 每个线程被封装为一个 **Node 节点**。
- 队列头节点(head)是当前持有资源的线程(独占模式)或已唤醒的节点。
3. **CAS 操作**:所有对 `state` 和队列头的修改,都通过 **`Unsafe.compareAndSwap`** 原子完成,保证线程安全。
**4 步关键操作**
可以把一个线程获取锁到释放锁的过程,想象成**“尝试加锁 → 排队等候 → 被唤醒 → 解锁”**的流程。
```mermaid
graph TD
A[线程请求资源] --> B{state == 0?}
B -->|是| C[CAS 修改 state]
C -->|成功| D[获取资源执行]
C -->|失败| E[加入 CLH 队列尾部]
B -->|否| E
E --> F[自旋检查前驱是否为 head]
F -->|是| G[再次 tryAcquire]
G -->|成功| H[设置自己为 head 执行]
G -->|失败| I[park 挂起等待唤醒]
F -->|否| I
D --> J[释放资源 state=0]
J --> K[unparkSuccessor 唤醒后继节点]
K --> F
```
**源码锚点(资深面试加分项)**
(1)**head 是 dummy node(哨兵节点)**:CLH 队列的 head 不保存有效线程(`node.thread == null`),它代表「最近一次成功获取锁」的位置占位。这样设计让 `acquireQueued` 的自旋条件统一为「我的前驱是 head 就再试一次 `tryAcquire`」,无需对队首做特判。
(2)**`acquireQueued` 的自旋条件**:
```java
final boolean acquireQueued(final Node node, int arg) {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 只有前驱是 head 才允许抢锁
setHead(node); // 自己成为新的 dummy head
p.next = null; // 帮助 GC
return false;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) { // 否则 park 挂起
...
}
}
}
```
关键设计:**只有 head 的后继节点才允许 `tryAcquire`**,其余节点直接 park。这把「队列中所有线程都在 CAS 抢锁」退化为「至多一个线程在抢」,将竞争收敛到队首,避免惊群效应。
(3)**`hasQueuedPredecessors` 是公平锁的核心**:`FairSync.tryAcquire` 在 CAS 前先调用它检查队列中是否有等待者,有才放弃抢锁去排队;`NonfairSync` 不检查直接 CAS。一个方法区分了公平与非公平。
(4)**`SIGNAL` 状态的责任转移**:线程 park 前会把前驱节点的 `waitStatus` 置为 `SIGNAL`(「我睡了,你释放锁时记得叫醒我」),把唤醒责任挂到前驱身上;`CANCELLED`(=1,如 `tryLock` 超时)节点在遍历中被跳过并清理。
::: info L4 深度:CLH 队列 vs MCS 队列的设计差异
:::
**CLH 队列 vs MCS 队列:为什么 AQS 选择 CLH 变体?**
CLH(Craig, Landin, and Hagersten)和 MCS(Mellor-Crummey and Scott)是两种经典的 FIFO 自旋锁队列算法,两者的核心差异决定了 AQS 的设计选择:
| 维度 | CLH 队列 | MCS 队列 | AQS 的 CLH 变体 |
| :--------------- | :-------------------------------------------- | :------------------------------------ | :------------------------------- |
| **自旋位置** | 在前驱节点上自旋(`pred.locked`) | 在本地节点上自旋(`myNode.locked`) | 不自旋,`park()` 挂起 |
| **链表方向** | 隐式单向链表(通过 `tail` + 前驱指针遍历) | 显式双向链表(`prev` + `next`) | 显式双向链表 |
| **本地 vs 远程** | 远程自旋:每个线程 watch 前驱节点状态 | 本地自旋:每个线程 watch 自身节点状态 | 本地阻塞:自身 park/unpark |
| **适配阻塞** | 原生不适合阻塞,若挂起则 successor 不知唤醒谁 | 天然适合:本地节点被前驱唤醒 | AQS 采用 MCS 思想 + CLH 队列结构 |
**AQS 为什么用 CLH 变体而非纯 CLH/MCS?**
1. **CLH 的自旋问题**:纯 CLH 每个线程在前驱节点上自旋(`while (pred.locked)`),在 Java 层面,CPU 空转会浪费资源。AQS 将其改为 `park()` 挂起,由前驱释放锁时 `unpark` 后继——这个「唤醒链」本质是将 CLH 的自旋检测替换为 OS 级阻塞。
2. **CLH 的单向链表问题**:纯 CLH 只有隐式单向链表(通过 tail CAS + 前驱引用),无法支持取消操作和条件队列。AQS 将其改造为显式双向链表(`prev` + `next`),以便:取消节点时通过 `next` 快速找到后继、条件队列信号时能反向找到自身在同步队列的位置。
3. **MCS 的冗余开销**:MCS 本身是双向链表 + 本地自旋,更接近 AQS 的最终形态。但 MCS 需要每个节点有 `locked` 标志位,而 AQS 通过 `waitStatus` 中的 `SIGNAL` 状态实现了更丰富的语义(取消、条件、传播),因此 AQS 本质上是「CLH 的队列结构 + MCS 的阻塞思想 + AQS 的状态机」。
> **面试记忆点**:AQS = CLH 的 FIFO 队列骨架 + MCS 的「前驱唤醒后继」阻塞机制 + 双向链表改造(取消/条件队列需求)。
::: info L4 深度:Linux futex 与 park/unpark 的关系
:::
**`LockSupport.park()` / `unpark()` 与 Linux futex 的对应关系**
Java 的 `LockSupport.park()` / `unpark()` 最终依赖操作系统的线程阻塞原语,在 Linux 上通过 `futex`(Fast Userspace muTEX)系统调用实现。
**futex 的核心设计**:futex 是 Linux 内核提供的混合同步原语——低竞争时在用户空间通过 CAS 完成(零系统调用),高竞争时才陷入内核阻塞线程。这正是 AQS 的「先 CAS 抢锁,抢不到再 park 入队」策略的 OS 层基础。
```
Java 层: LockSupport.park(this) / LockSupport.unpark(thread)
│
JVM 层: Unsafe_Park / Unsafe_Unpark (HotSpot)
│
OS 层: pthread_cond_wait / pthread_cond_signal (POSIX)
│ 或
futex(FUTEX_WAIT, ...) / futex(FUTEX_WAKE, ...) (Linux)
```
**关键机制对比**:
| 维度 | `park()`/`unpark()` | Linux futex | pthread mutex |
| :----------- | :------------------------------------------------------------------------------------------------ | :---------------------------------------------- | :----------------------------- |
| **许可机制** | `unpark()` 先于 `park()` 调用时,`permit` 被记录,后续 `park()` 立即返回(类似 binary semaphore) | `FUTEX_WAIT` 严格在值相等时才等待(无许可累积) | 无许可累积,严格互斥 |
| **唤醒语义** | `unpark()` 针对特定线程(一对一) | `FUTEX_WAKE` 可唤醒 N 个等待者(一对多) | `pthread_cond_signal` 唤醒一个 |
| **超时支持** | `parkNanos()` / `parkUntil()` | `FUTEX_WAIT` 自带 timeout 参数 | `pthread_cond_timedwait` |
**为什么 `unpark()` 可以先于 `park()` 调用?**
这是一个面试高频细节:`LockSupport` 内部维护每个线程的 `permit` 标志(boolean)。`unpark(t)` 将 `permit` 设为 1(如果已是 1 则不变),`park()` 将 `permit` 设为 0 并返回(如果已是 0 则阻塞)。因此 `unpark()` → `park()` 的顺序不阻塞,而 `park()` → `unpark()` 的顺序正常唤醒。
这与 futex 的 `FUTEX_WAIT` 行为不同——`FUTEX_WAIT` 要求 `*addr == val`(原子比较),若值已被修改则不等待。AQS 的 `SIGNAL → unpark` 正是利用了这一语义:释放线程 CAS 修改 `state` 后再 `unpark`,被唤醒线程重新 `tryAcquire` 时能立刻感知状态变化。
> **面试记忆点**:`park/unpark` = `permit` 机制(类似信号量)+ futex 内核阻塞;`SIGNAL` 状态是 AQS 层对 futex 的适配,确保「先改 state 再唤醒」的可见性顺序。
::: info AQS 独占模式工作流程
:::
**独占模式**:同一时刻仅允许一个线程获取同步状态,例如 `ReentrantLock`。
1. **tryAcquire**:线程尝试直接获取锁(CAS 修改 state)。
- **成功**:拿到锁,设置自己为独占线程。
- **失败**:进入第 2 步。
2. **addWaiter**:将当前线程包装成 Node 节点,**用 CAS 快速插入**到同步队列尾部。
3. **acquireQueued**:在队列中进入“**自旋-检测-挂起**”循环。
- 检查自己是不是第二个节点(即 head 的下一个),如果是则再次尝试获取锁(tryAcquire)。
- 如果获取失败,则根据前驱节点的状态,决定是否将自己挂起(`LockSupport.park`)。
4. **release & unparkSuccessor**:持有锁的线程释放锁时。
- `tryRelease`:修改 state。
- 唤醒(`LockSupport.unpark`)队列中下一个等待的节点(后继节点),让它重新尝试获取锁。
::: info AQS 共享模式工作流程
:::
**共享模式**:同一时刻允许多个线程获取同步状态,例如 `CountDownLatch` 和 `Semaphore`。
- **获取锁**:
- 线程调用 `acquireShared(int)` → `tryAcquireShared(int)`(子类实现)。
- 如果成功(返回 `≥0`),获取锁;否则进入队列等待。
- **释放锁**:
- 线程调用 `releaseShared(int)` → `tryReleaseShared(int)`(子类实现)。
- 如果成功,唤醒后续等待的线程(可能多个)。
::: info AQS 关键方法
:::
AQS 的关键方法采用模板方法设计模式串联起来:
- **独占模式**
- **`tryAcquire(int arg)`**:尝试以独占模式获取同步状态,此方法需由子类实现。
- **`acquire(int arg)`**:以独占模式获取同步状态,若获取失败则将线程加入队列并阻塞。
- **`tryRelease(int arg)`**:尝试以独占模式释放同步状态,需子类实现。
- **`release(int arg)`**:以独占模式释放同步状态,若释放成功则唤醒队列中的后继节点。
- **共享模式**
- **`tryAcquireShared(int arg)`**:尝试以共享模式获取同步状态,需子类实现。
- **`acquireShared(int arg)`**:以共享模式获取同步状态,若获取失败则将线程加入队列并阻塞。
- **`tryReleaseShared(int arg)`**:尝试以共享模式释放同步状态,需子类实现。
- **`releaseShared(int arg)`**:以共享模式释放同步状态,若释放成功则唤醒队列中的后继节点。
::: tip 扩展
[从 ReentrantLock 的实现看 AQS 的原理及应用](https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html)
:::
### 【中等】synchronized 和 ReentrantLock 有什么区别?⭐⭐⭐⭐⭐
- `ReentrantLock` **更强大**:支持公平锁、可中断、超时、多条件变量。`ReentrantLock` **必须手动释放锁**,否则会导致死锁!
- `synchronized` **更简单**:自动管理锁,适合基础同步需求。
- **性能差异**:JDK 6 后,synchronized 经过一系列优化,两者性能接近,但 `ReentrantLock` 在高竞争场景仍略有优势。

:::info synchronized 和 ReentrantLock 详细对比
:::
以下是 **`synchronized`** 和 **`ReentrantLock`** 的详细对比表格,涵盖 **锁机制、功能、性能、使用场景** 等核心维度:
---
| **对比维度** | **`synchronized`** | **`ReentrantLock`** |
| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
| **锁类型** | JVM 内置关键字(隐式锁) | JDK 提供的类(显式锁) |
| **加锁解锁方式** | 自动加锁/释放锁(进入同步代码块加锁,退出时释放) | 需手动调用 `lock()` 和 `unlock()`(必须配合 `try-finally` 使用) |
| **是否可重入** | 支持(同一线程可重复获取) | 支持(同一线程可重复获取) |
| **是否支持公平** | 仅支持非公平锁 | 可配置公平锁或非公平锁(构造函数传参 `true/false`) |
| **是否可中断** | 不支持中断 | 支持 `lockInterruptibly()`,可响应中断 |
| **是否支持超时** | 不支持超时 | 支持 `tryLock(timeout, unit)`,可设置超时时间 |
| **是否支持多条件** | 通过 `wait()`/`notify()` 实现,单一等待队列 | 支持多个 `Condition`,可精确控制线程唤醒(如 `await()`/`signal()`) |
| **性能** | JDK 6+ 优化后(偏向锁→轻量级锁→重量级锁)性能接近 `ReentrantLock` | 在高竞争场景下性能略优(减少上下文切换) |
| **死锁检测** | 无内置死锁检测 | 可通过 `tryLock` 避免死锁 |
| **适用场景** | 简单同步场景(如单方法同步) | 复杂同步需求(如公平锁、可中断锁、超时锁) |
| **底层实现** | JVM 通过 `monitorenter`/`monitorexit` 字节码实现 | 基于 `AbstractQueuedSynchronizer (AQS)` 实现 |
:::info synchronized 和 ReentrantLock 的使用差异
:::
::: code-tabs#synchronized 和 ReentrantLock 使用差异
@tab synchronized 使用
```java
// 1. 用于代码块
synchronized (this) {}
// 2. 用于对象
synchronized (object) {}
// 3. 用于方法
public synchronized void test () {}
// 4. 可重入
for (int i = 0; i < 100; i++) {
synchronized (this) {}
}
```
@tab ReentrantLock 使用
```java
public void test () throw Exception {
// 1. 初始化选择公平锁、非公平锁
ReentrantLock lock = new ReentrantLock(true);
// 2. 可用于代码块
lock.lock();
try {
try {
// 3. 支持多种加锁方式,比较灵活;具有可重入特性
if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
} finally {
// 4. 手动释放锁
lock.unlock()
}
} finally {
lock.unlock();
}
}
```
:::
:::info synchronized 和 ReentrantLock 的适用场景
:::
- **`synchronized` 适用场景**:单例模式的双重检查锁、简单的线程安全计数器。
- **`ReentrantLock` 适用场景**:
- 需要公平性的任务队列(如订单处理)。
- 需要超时控制的资源争用(如避免死锁)。
- 复杂的多条件线程协调(如生产者-消费者模型)。
**使用选择建议**
- **选择 `synchronized`**:
- 需要简单的代码块同步。
- 不需要高级功能(如超时、公平锁)。
- **选择 `ReentrantLock`**:
- 需要精细控制(如公平性、可中断)。
- 需要避免死锁(`tryLock`)。
### 【困难】ReentrantLock 的实现原理是什么?⭐⭐⭐⭐
本质上,**ReentrantLock 是 AQS 在独占模式下的一个经典实现**。
**ReentrantLock 以 AQS 的 state 和同步队列为基础,通过 NonfairSync / FairSync 实现(非)公平策略,并内置可重入计数和条件队列机制的互斥锁实现**。
- **核心依赖**:`ReentrantLock` 通过内部类 `Sync`(继承 `AQS`)实现锁机制。
- **AQS 作用**:提供线程阻塞/唤醒的队列管理(CLH 变体)和状态(`state`)的原子操作。
:::info 两种模式
:::
ReentrantLock 内部有两个主要的静态内部类,决定了其抢占行为:
1. **`NonfairSync`(非公平锁,默认)**:**允许插队**
- 新线程来了直接尝试 CAS 抢锁(插队),抢不到才排队。
- **优点**:吞吐量高。
- **缺点**:可能导致饥饿问题。
2. **`FairSync`(公平锁)**:**先到先得**
- 新线程来了先检查同步队列是否为空,有排队者则直接去队尾排队。
- **优点**:公平,无饥饿问题。
- **缺点**:上下文切换多,吞吐量相对低。
二者核心区别就在 `lock()` 方法中,尝试获取锁前**是否检查同步队列中有等待者**(`hasQueuedPredecessors()`)。
:::info 三大核心
:::
```java
ReentrantLock
│
├── Sync (extends AQS)
│ ├── state (锁计数器)
│ ├── exclusiveOwnerThread (当前持有线程)
│ └── CLH Queue (等待锁的线程队列)
│
├── NonfairSync (默认,插队抢锁)
├── FairSync (先来后到)
│
└── ConditionObject
└── Condition Queue (等待特定条件的线程队列)
```
1. **AQS 同步器(Sync)**:继承自 AQS。
- **状态 (`state`)**:`volatile int`,表示锁被持有的次数。`0`=空闲,`N`=被同一个线程重入了 `N` 次。
- **同步队列**:存储等待线程的 CLH 变体队列。
- **独占线程**:记录当前持有锁的线程 (`exclusiveOwnerThread`)。
2. **可重入机制**:
- **加锁**:若当前线程是持有者,则 `state` 加 1(无需 CAS)。
- **解锁**:`state` 减 1,减到 0 时才完全释放,唤醒后继节点。
3. **条件变量 (`ConditionObject`)**:
- 每个 `Condition` 对象内部维护一个 **独立的等待队列**。
- `await()` 将当前线程从**锁的同步队列**移到**条件等待队列**,并释放锁。
- `signal()` 将条件等待队列的头节点移到**锁的同步队列**中,重新等待获取锁。
:::info 关键步骤
:::
**🔒 加锁四步曲**
```mermaid
graph TD
A[线程调用 lock] --> B[CAS 尝试将 state 0→1]
B -->|成功| C[设置 exclusiveOwnerThread 执行临界区]
B -->|失败| D{当前线程已持有锁?}
D -->|是| E[state+1 重入]
D -->|否| F[加入 CLH 同步队列尾部]
F --> G[自旋检查前驱是否为 head]
G -->|是| B
G -->|否| H[park 挂起等待唤醒]
C --> I[调用 unlock]
E --> I
I --> J[state-1]
J --> K{state == 0?}
K -->|是| L[释放锁 唤醒后继节点]
K -->|否| M[仍持有锁]
```
1. **快速抢票**:新线程直接 CAS 尝试将 `state` 从 0 改为 1(插队)。
2. **抢到则坐**:成功则设置自己为独占线程,进入临界区。
3. **没抢则排**:失败则调用 AQS 的 `acquire(1)`,进入同步队列队尾。
4. **队列中等**:在队列中进入“自旋-检查-挂起”循环,等待被前驱节点唤醒。
**🔓 解锁两步曲**(`unlock()` 本质是两个关键操作)
1. **尝试释放**:调用 `tryRelease(1)`,将 `state` 减 1。如果 `state` 减到 0,则清空独占线程标记。
2. **唤醒后继**:如果锁完全释放(`state == 0`),则唤醒同步队列中下一个符合条件的等待线程。
::: info L4 深度:C++ std::mutex 与 Java ReentrantLock 的跨语言对比
:::
**C++ std::mutex vs Java ReentrantLock:设计哲学的核心分歧**
| 维度 | C++ `std::mutex` | Java `ReentrantLock` |
| :------------------ | :-------------------------------------------------------- | :----------------------------------------------- |
| **可重入性** | **不可重入**(同一线程重复 lock 是 UB,通常死锁) | **可重入**(`state` 累加计数) |
| **公平性** | 标准不规定(通常实现为非公平,无公平模式 API) | 构造参数控制公平/非公平 |
| **条件变量** | 分离设计:`std::condition_variable` + `std::unique_lock` | 内聚设计:`lock.newCondition()` 返回 `Condition` |
| **超时机制** | `std::timed_mutex`(独立类型);`std::unique_lock` 不参与 | `tryLock(long, TimeUnit)` 直接内置 |
| **RAII 封装** | `std::lock_guard` / `std::unique_lock`(C++ 核心惯用法) | 无标准 RAII wrapper(需手动 try-finally) |
| **底层实现** | Linux:`futex`(低竞争用户态 CAS,高竞争内核阻塞) | 同样基于 futex(通过 `park`/`unpark`) |
| **recursive_mutex** | 需显式使用 `std::recursive_mutex`(独立类型) | 默认即可重入(同类型) |
**C++ 为什么把 mutex 和 condition_variable 分开?**
C++ 遵循「单一职责」原则:
- `std::mutex` 只负责互斥(lock/unlock)
- `std::condition_variable` 只负责等待-通知(wait/notify)
- `std::unique_lock` 负责 RAII 式的锁生命周期管理
三者组合使用:
```cpp
std::mutex mtx;
std::condition_variable cv;
std::queue queue;
// 生产者
{
std::lock_guard lock(mtx); // RAII 自动释放
queue.push(data);
}
cv.notify_one(); // 通知在锁外,避免「惊群」
// 消费者
std::unique_lock lock(mtx); // 可手动 unlock
cv.wait(lock, [&]{ return !queue.empty(); }); // 谓词检查防虚假唤醒
```
**Java ReentrantLock 为什么把 Condition 内聚在锁中?**
Java 的设计哲学是「便利性 + 正确性优先」:
1. **`newCondition()` 绑定锁实例**:避免 C++ 中 `condition_variable` 与错误的 `mutex` 搭配使用。Java 的 `Condition` 在 `await()` 时必须持有关联锁,编译期 + 运行期双重校验,杜绝 C++ 中「condition_variable 用错 mutex」的未定义行为。
2. **`await()` 原子释放锁再阻塞**:Java 的 `await()` 内部自动完成「原子释放锁 → 加入条件队列 → park 阻塞」的三步操作,而 C++ 的 `wait()` 需要外部先 `unique_lock` 再传入——虽然效果等价,但 Java 的内聚设计降低了误用概率。
3. **`signal()` 到 `lock` 的转移链**:被 `signal()` 的线程从条件队列移至 AQS 同步队列,等待重新获取锁——这个转移在 AQS 内部原子完成。C++ 无此机制,被 notify 的线程需重新竞争锁,可能面临优先级反转。
**`std::unique_lock` vs Java ReentrantLock 的设计哲学**
| 维度 | C++ `std::unique_lock` | Java `ReentrantLock` |
| :------------- | :---------------------------------- | :----------------------------------------------------------------- |
| **所有权模型** | move-only(所有权可转移) | 无所有权转移概念(任意线程可 unlock,但语义上只有持有者应 unlock) |
| **生命周期** | RAII 自动管理(构造加锁,析构释放) | 手动 try-finally(无析构保证) |
| **灵活性** | 可延迟加锁、提前解锁、转移所有权 | `tryLock` 支持超时/非阻塞,但无所有权转移 |
| **性能** | 零开销抽象(编译期内联,无虚函数) | JIT 优化 + 偏向锁消除,热点路径可达 native |
> **面试记忆点**:C++ 是「积木式组合」——mutex + condition_variable + unique_lock 三者松耦合换取最大灵活性;Java 是「一体化工具」——ReentrantLock 内置公平性、可重入、Condition、超时,牺牲部分灵活性换取开箱即用的正确性。C++ 的 `recursive_mutex` 独立成类型说明可重入是有代价的(通常暗示设计问题),而 Java 默认支持可重入反映了「方便优先」的实用主义。
### 【困难】ReentrantReadWriteLock 的实现原理是什么?⭐⭐⭐
::: info ReentrantReadWriteLock 的特性
:::
**ReentrantReadWriteLock 是为【读多写少】的并发场景设计的锁实现**。
**ReentrantReadWriteLock 允许多个线程同时持有读锁,但同一时刻只允许一个线程持有写锁**。此外,存在读锁时无法获取写锁,存在写锁时无法获取读锁。
ReentrantReadWriteLock 有以下特性:
- **可重入**:读写锁都支持可重入。
- **支持公平锁**,默认为非公平锁。
- **支持锁降级**:**持有写锁可以获取读锁;反之不允许**。
::: info ReentrantReadWriteLock 的核心设计
:::
**ReentrantReadWriteLock 基于 AQS 实现的读写锁**。
`ReentrantReadWriteLock` 的**核心设计思想**是**将一个 32 位的 int 状态变量拆分为两部分**:`state = (readCount << 16) | writeCount`
虽然提供了两个锁对象(`readLock`, `writeLock`),但底层共享同一个 AQS 同步器。
| 视角 | 读锁 (`ReadLock`) | 写锁 (`WriteLock`) |
| :------------- | :--------------------- | :------------------------- |
| **行为** | 共享锁 | 独占锁 |
| **占用 state** | 高 16 位 | 低 16 位 |
| **互斥规则** | 与**写锁**互斥 | 与**所有锁**(读、写)互斥 |
| **重入计数** | 所有读线程的总重入次数 | 单个写线程的重入次数 |
| **条件变量** | **不支持** `Condition` | **支持** `Condition` |
::: info ReentrantReadWriteLock 写锁实现(WriteLock)
:::
**ReentrantReadWriteLock 写锁基于 AQS 的独占模式实现**。
写锁获取步骤:
1. 线程申请写锁(`writeLock.lock()`)
2. 检查有没有读锁或写锁(`state != 0`)
3. 没有,**CAS 设置 state 的低 16 为 1**(获得写锁)
4. 有,当前线程是否已持有写锁(可重入)
- 是:CAS 将 state 的低 16 加 1(获得写锁)
- 否:排队等待(进入 AQS 同步队列挂起)
实现方法:
```java
protected final boolean tryAcquire(int acquires) {
// 检查是否有读锁或其他线程持有写锁
if (c != 0 && w == 0) return false;
// 检查重入或 CAS 设置状态
// ...
}
```
::: info ReentrantReadWriteLock 读锁实现(ReadLock)
:::
**ReentrantReadWriteLock 读锁基于 AQS 的共享模式实现**。
1. 线程申请读锁(`readLock.lock()`)
2. 检查有没有写锁(`(state & 0xFFFF) != 0`)
3. 没有,CAS 将 state 的高 16 加 1(获得读锁)
4. 有,排队等待(进入 AQS 同步队列挂起)
::: info ReentrantReadWriteLock 锁降级实现
:::
1. **线程持有写锁**
2. **直接申请读锁**:因为线程有写锁,因此一定成功(高 16 位加 1)
3. **释放写锁**
4. 锁状态从 **“独占写”** 降级为 **“共享读”**。
```java
// 锁降级示例代码
writeLock.lock(); // 获取写锁
try {
// 修改数据。..
readLock.lock(); // 在保持写锁的情况下获取读锁(锁降级关键步骤)
} finally {
writeLock.unlock(); // 释放写锁,降级为读锁
}
// 此时仍持有读锁,其他线程可以获取读锁但不能获取写锁
```
**性能优化技巧**
- **firstReader 优化**:记录第一个获取读锁的线程,避免 ThreadLocal 查找
- **cachedHoldCounter**:缓存最近一个获取读锁的线程计数器
- **读锁计数存储**:使用 ThreadLocal 保存每个线程的重入次数,避免竞争
### 【困难】StampedLock 的实现原理是什么?⭐⭐
`StampedLock`是 JDK8 引入的高性能锁,**适合读多写少且追求极致吞吐的场景**,但需谨慎处理乐观读失败和死锁风险。
StampedLock 是 **通过一个 64 位 long 值同时编码版本号、读计数和写标记,并利用戳记(Stamp)实现乐观读、锁升级等高级并发控制,在牺牲部分易用性和重入性的前提下,提供极高读性能的同步器**。
::: info StampedLock 三种模式
:::
StampedLock 的状态存储在一个 `long` 型(64 位)变量中,分为三个逻辑部分:
| 模式 | 占用位 | 作用 |
| :--------- | :----------- | :---------------------------------------------------------------------------------- |
| **读锁** | 低 7 位 | 读线程计数(实际是`readerCount+1`) |
| **写锁** | 第 8 位 | 独占标记,0 未占用,1 已占用 |
| **版本号** | 未使用的高位 | 加锁返回有效戳,解锁需验证戳;
戳无效 = 状态变更(有写操作),戳有效 = 数据一致 |
状态流转:
```
初始状态:state = (version: 0, readCount: 0, write: 0)
[操作与状态变化示例]
1. 写锁获取 (`writeLock()`)
-> state 低 8 位置 1,同时整个 long 值改变 (version++)
-> 返回 stamp W1
2. 乐观读 (`tryOptimisticRead()`)
-> 不修改 state!仅记录当前 state 值作为 stamp O1
-> 校验时:比较当前 state 是否等于 O1
3. 读锁获取 (`readLock()`)
-> 高 56 位读计数+1 (如果写锁未被占用)d
-> 返回 stamp R1
4. 锁升级 (`tryConvertToWriteLock(R1)`)
-> 原子操作:读计数-1,同时写标记置 1
-> 成功返回新 stamp W2,失败返回 0
```
::: info StampedLock 乐观锁
:::
StampedLock 乐观锁是一种 **“读时复制+版本校验”** 的乐观并发控制。
乐观锁在读远多于写且写操作不频繁的场景下,性能极高(完全无锁)。
StampedLock 乐观锁流程
1. 调用 `tryOptimisticRead()` 获取一个**戳记(Stamp)**,此时**完全不阻塞**。
2. 读取共享数据到局部变量。
3. 调用 `validate(stamp)` 校验:**自获取戳记以来,是否有写锁被获取过?**
- **无变化**:数据有效,直接使用。
- **有变化**:升级为**悲观读锁**,重新读取数据。
::: info StampedLock 锁升级
:::
**读锁 → 写锁升级**:`tryConvertToWriteLock(stamp)`
- **前提**:当前线程已持有读锁(`stamp` 是有效的读锁戳记)。
- **过程**:原子地尝试释放读计数,获取写锁标记。
- **结果**:成功返回新写戳记,失败返回 0。
- **注意**:**可能死锁**!如果当前还有其他读锁持有者,升级会失败(因为读写互斥)。
**乐观读 → 写锁升级**:
- 乐观读戳记本身不代表持有锁,升级失败是常态。
- 通常先获取悲观读锁,再进行升级尝试。
::: info StampedLock vs. ReentrantReadWriteLock
:::
| 特性 | `StampedLock` | `ReentrantReadWriteLock` |
| ------------ | -------------------- | ------------------------ |
| **读并发度** | 最高(乐观读无阻塞) | 高(悲观读阻塞写) |
| **写饥饿** | 可能发生 | 非公平模式下可能发生 |
| **锁重入** | 不支持 | 支持 |
| **公平性** | 仅非公平 | 支持公平/非公平 |
| **条件变量** | 不支持 | 支持 |
::: info StampedLock 使用示例
:::
```java
StampedLock lock = new StampedLock();
// 乐观读示例
long stamp = lock.tryOptimisticRead();
// 读取共享数据。..
if (!lock.validate(stamp)) {
// 版本失效,转悲观读
stamp = lock.readLock();
try {
// 重新读取数据。..
} finally {
lock.unlockRead(stamp);
}
}
// 写锁示例
long stamp = lock.writeLock();
try {
// 修改数据。..
} finally {
lock.unlockWrite(stamp);
}
```
### 【中等】Condition 的原理是什么?与 Object.wait/notify 有什么区别?⭐⭐⭐
**Condition 是 Lock 接口的条件变量,提供比 Object.wait/notify 更灵活的线程等待/通知机制**。一个 Lock 可以创建多个 Condition,实现精确唤醒。
**(1)Condition 的核心结构**
`ConditionObject` 是 AQS 的内部类,维护一个**独立的等待队列**(单向链表):
```java
public class ConditionObject implements Condition {
private transient Node firstWaiter; // 等待队列首节点
private transient Node lastWaiter; // 等待队列尾节点
}
```
**(2)核心机制**
```mermaid
graph TD
A[线程调用 await] --> B[创建 Node 加入条件队列]
B --> C[释放锁 fullyRelease]
C --> D[park 挂起线程]
D --> E[被 signal 唤醒]
E --> F[从条件队列移到同步队列]
F --> G[重新竞争锁]
G --> H[恢复执行]
```
- **`await()`**:当前线程包装为 Node 加入条件队列,**完全释放锁**,park 挂起。被唤醒后从条件队列转移到同步队列,重新竞争锁。
- **`signal()`**:将条件队列的首节点转移到同步队列,唤醒该线程(注意只是转移,线程需重新竞争锁)。
**(3)与 Object.wait/notify 的对比**
| 特性 | `Condition` | `Object.wait/notify` |
| ---------------- | ----------------------------------- | -------------------------- |
| **依赖** | `Lock`(如 ReentrantLock) | `synchronized` |
| **等待队列数量** | 可创建多个,支持精确唤醒 | 仅一个,notifyAll 唤醒所有 |
| **中断响应** | `awaitUninterruptibly()` 不响应中断 | `wait()` 必响应中断 |
| **超时** | `awaitUntil(date)` 支持截止时间 | `wait(timeout)` 支持超时 |
| **不释放锁场景** | 无(await 必释放锁) | 无(wait 必释放锁) |
**(4)典型应用:生产者-消费者精确唤醒**
```java
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 队列未满条件
Condition notEmpty = lock.newCondition(); // 队列非空条件
// 生产者:队列满时等待 notFull,生产后唤醒 notEmpty
// 消费者:队列空时等待 notEmpty,消费后唤醒 notFull
```
### 【困难】AQS 的 CLH 队列与原始 CLH 队列有什么区别?⭐⭐
AQS 的等待队列是 **CLH(Craig, Landin, and Hagersten)队列的变体**,并非原始 CLH 队列。
**(1)原始 CLH 队列**
- 基于隐式链表,每个节点只持有前驱节点的引用。
- 自旋等待:线程在前驱节点的状态上自旋(spin on predecessor)。
- 只支持独占模式。
**(2)AQS 的 CLH 变体**
| 特性 | 原始 CLH | AQS CLH 变体 |
| ------------ | ------------------ | --------------------------------- |
| **队列结构** | 隐式链表(仅前驱) | 显式双向链表(prev + next) |
| **等待方式** | 自旋 | park 阻塞(避免 CPU 浪费) |
| **模式** | 仅独占 | 独占 + 共享 |
| **状态判断** | 前驱节点的状态 | 前驱节点的 waitStatus |
| **取消处理** | 无 | 支持 CANCELLED 状态,节点可被清理 |
| **条件队列** | 无 | ConditionObject 独立的条件队列 |
**(3)为什么选择阻塞而非自旋?**
- 自旋在**等待时间长**的场景浪费 CPU。
- AQS 通过 `LockSupport.park()` 挂起线程,`unpark()` 唤醒,交由 OS 调度。
- 仅在「自旋检查前驱状态」时短暂自旋,避免不必要的 park/unpark 开销。
**(4)入队与出队的关键逻辑**
```java
// 入队:CAS 设置 tail
Node node = new Node(thread);
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
// 出队:head 指向新节点,旧 head 被 GC
setHead(node);
node.prev = null;
```
**(5)为什么是双向链表?**
- 取消节点时需找到前驱来断开链接(单向链表需遍历,O(n))。
- 检查是否为 head 的后继时需要反向遍历。
- 共享模式唤醒需要向后传播。
## Java 无锁
### 【中等】什么是 CAS?CAS 的实现原理是什么?⭐⭐⭐⭐⭐
::: info 什么是 CAS?
:::
CAS 是 **Compare-And-Swap(比较并交换)** 的缩写,是实现并发编程的**无锁原子操作**核心。
CAS 核心规则是:先比较内存中某个值是否等于预期值,若相等则将其更新为新值;若不等则不操作,整个过程原子性完成。
CAS 操作伪代码
```java
boolean CAS(Variable var, int expected, int newValue) {
if (var.value == expected) { // 比较当前值是否等于预期值
var.value = newValue; // 如果相等,更新为新值
return true;
}
return false; // 否则失败
}
```
说明:
1. 读取内存值 `V`。
2. 比较 `V` 和预期值 `A`:
- 如果 `V == A`,说明没有其他线程修改过,更新为 `B`。
- 如果 `V != A`,说明值已被修改,放弃更新。
3. 返回操作是否成功。
```mermaid
graph TD
A[线程读取内存值 V] --> B{V == 预期值 A?}
B -->|是| C[原子更新 V = B]
C --> D[返回成功 true]
B -->|否| E[放弃更新]
E --> F{是否需要自旋重试?}
F -->|是| A
F -->|否| G[返回失败 false]
```
::: info CAS 特性
:::
- **无锁**:无需加 synchronized/Lock,减少线程阻塞 / 唤醒开销,性能更高;
- **原子性**:CPU 指令级保证,比手动加锁更可靠;
- **ABA 问题**:V 先从 A 变 B 再变回 A,CAS 会误判为未修改(解决:加版本号,如 `AtomicStampedReference`)。
::: info CAS 的实现原理是什么?
:::
**Java 层面,通过 `Unsafe` 类调用 native 方法(如 `compareAndSwapInt()`)实现 CAS**。
```java
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int newValue);
```
更底层(CPU 层面),CAS 实现依赖于 CPU 提供的原子指令(如 x86 的 `cmpxchg` 指令)。
**从 native 到硬件的完整链路**:
- HotSpot 为 `compareAndSwapInt` 生成的机器码是 `lock cmpxchg`(x86):`cmpxchg` 本身只保证单次比较交换的语义,**`lock` 前缀锁总线/锁缓存行**,保证多核下的原子性。
- `lock` 前缀同时带来两个副作用:将当前核心缓存行写回主内存并使其他核心的缓存行失效(可见性);充当全量内存屏障,禁止指令重排序(有序性)。这就是为什么 CAS 变量不需要再叠加 `volatile` 做读写同步——但 Atomic 类的 `value` 字段本身仍是 `volatile`,保证普通 `get()` 的可见性。
- 高竞争下 `lock cmpxchg` 的缓存行乒乓(cache line bouncing)是性能瓶颈根源,`LongAdder` 的分段 Cell 正是为此而生(见下文 LongAdder 题)。
::: info CAS 典型应用
:::
**(1)原子类**
```java
AtomicInteger atomicInt = new AtomicInteger(0);
atomicInt.incrementAndGet(); // CAS 实现原子自增
```
**底层实现**:
```java
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
```
**(2)自旋锁**
```java
while (!CAS(lock, 0, 1)) { // 尝试获取锁
// 自旋等待
}
```
**(3)无锁数据结构**
- `ConcurrentHashMap`(JDK 8 使用 CAS + `synchronized` 替代分段锁)。
- `CopyOnWriteArrayList`(CAS 保证写入原子性)。
::: info L4 深度:x86 CMPXCHG 指令 + LOCK 前缀详解
:::
**x86 `CMPXCHG` 指令的微架构执行流程**
`CMPXCHG` 是 x86 架构的核心原子指令,其语义是:比较 `EAX`(累加器)与目标内存操作数,若相等则将源操作数写入目标,否则将目标值加载到 `EAX`。
```asm
; CMPXCHG [mem], r (Intel 语法)
; 伪代码:
TEMP = [mem]
IF EAX == TEMP:
ZF = 1 ; 设置零标志
[mem] = r ; 写入新值
ELSE:
ZF = 0 ; 清除零标志
EAX = TEMP ; 将旧值加载到 EAX
```
**`LOCK` 前缀的硬件行为**(多核原子性保证):
`CMPXCHG` 本身不是原子的!在多核环境下,若无 `LOCK` 前缀,两个核心可能同时执行 `CMPXCHG` 并都认为自己成功。`LOCK` 前缀通过以下机制保证原子性:
1. **锁缓存行(Cache Lock)**:现代 CPU(P6 之后)通过 MESI 协议锁定目标地址所在的缓存行(断言 `#LOCK` 信号),而非锁整个总线。锁期间该缓存行在其它核心处于 `Invalid` 状态。
2. **总线锁兜底**:当操作数跨缓存行边界(非对齐)或无法锁缓存行时,回退到锁整个前端总线(bus lock),代价极高(~100 cycles+ vs ~20-40 cycles)。
3. **内存屏障效应**:`LOCK` 前缀隐式充当全量内存屏障(full fence)——所有之前的 load/store 对之后可见,禁止 StoreLoad 重排序。这是 CAS 提供 `volatile`-like 可见性的硬件根源。
**`lock cmpxchg` 的完整时序**(x86 多核视角):
```
Core-0 Core-1
│ │
├─ lock cmpxchg [addr], r │
├─ 发出 RFO(Read-For-Ownership) │
├─ 获取 addr 所在缓存行的独占权 │
├─ MESI: E → M(Modified) │ 缓存行被 Invalidate
├─ EAX vs [addr] 比较并写入 │
├─ 缓存行写回(或保持 M 状态) │
└─ 释放 lock 信号 ├─ 下次访问 addr 时 Cache Miss
└─ 重新从 Core-0 获取最新值
```
**x86 vs ARM(LDREX/STREX / LL/SC)的 CAS 实现差异**
| 维度 | x86(CMPXCHG) | ARM(LDREX/STREX) |
| :------------- | :------------------------------------------- | :--------------------------------------------------------------------------- |
| **指令模式** | 单指令(CMPXCHG 一条完成比较+交换) | 双指令:`LDREX`(加载+标记)→ `STREX`(条件存储) |
| **原子性保证** | `LOCK` 前缀锁总线/缓存行 | **Exclusive Monitor**(硬件监视器)检测 LDREX→STREX 之间是否有其他写入 |
| **ABA 敏感性** | 值与预期相同即成功(ABA 不感知) | `STREX` 失败即表示有他人写入(中间状态变化感知) |
| **限制** | 操作数必须对齐,跨缓存行退化为总线锁 | `LDREX/STREX` 之间不能有复杂操作(通常几十条指令内),否则硬件监视器可能超时 |
| **Java 适配** | `Unsafe::compareAndSwapInt` → `lock cmpxchg` | `Unsafe::compareAndSwapInt` → `ldrex/strex` 循环(LL/SC 循环) |
**ARM LL/SC 的 CAS 模拟**:由于 ARM 没有单指令 CAS,HotSpot 用 LL/SC 循环模拟:
```asm
// ARM 上的 CAS 伪汇编
retry:
LDREX r1, [r0] // Load-Link: 加载值并标记地址
CMP r1, r2 // 比较期望值
BNE fail // 不相等则失败
STREX r3, r3, [r0] // Store-Conditional: 条件存储
CMP r3, #0 // 检查 STREX 是否成功
BNE retry // 失败则重试(被其他核心打断)
fail:
// 返回结果
```
`STREX` 的返回值(r3)为 0 表示成功;非 0 表示 Exclusive Monitor 检测到地址被修改(其他核心写入、中断、上下文切换都可能导致 Monitor 清除),需重试整个 LL/SC 序列。
::: info L4 深度:ABA 问题在 C++ shared_ptr 中的表现
:::
**ABA 问题的跨语言视角:C++ `std::shared_ptr` 的 ABA 陷阱**
`std::shared_ptr` 是 C++ 的引用计数智能指针,当它与无锁数据结构结合时,ABA 问题会以更危险的形式出现。
**场景:lock-free stack 的 ABA 致危代码**
```cpp
template
class LockFreeStack {
struct Node { T data; Node* next; };
std::atomic head;
void push(T val) {
Node* n = new Node{val};
do {
n->next = head.load();
} while (!head.compare_exchange_weak(n->next, n));
}
std::shared_ptr pop() { // ← 危险!
Node* old_head;
do {
old_head = head.load();
if (!old_head) return nullptr;
} while (!head.compare_exchange_weak(old_head, old_head->next));
auto res = std::make_shared(old_head->data);
delete old_head; // ← 问题核心:直接 delete
return res;
}
};
```
**ABA 攻击时序**:
```
时刻 T1: Thread-A 读到 head = Node-A(next = Node-B)
时刻 T2: Thread-A 被挂起(操作系统调度)
时刻 T3: Thread-B pop 了 Node-A,delete Node-A,再 pop 了 Node-B
时刻 T4: Thread-B push 新节点,恰好分配到已释放 Node-A 的内存地址 → head = Node-C(address==&Node-A)
时刻 T5: Thread-A 恢复,CAS(head, Node-A, Node-B) → 成功!但 head 应指向 Node-C...
结果:Node-C 丢失,内存泄漏 + 数据丢失
```
**C++ 的解法**:`hazard pointer`(危险指针)或 `epoch-based reclamation`(基于 epoch 的回收)。核心思想:延迟释放内存直到确认没有线程持有该指针。
**Java 的 `AtomicStampedReference` vs C++ `shared_ptr` 思路对比**:
| 维度 | Java `AtomicStampedReference` | C++ `std::shared_ptr` |
| :------------------ | :----------------------------------------- | :------------------------------------------------------------- |
| **解决 ABA 的方式** | 版本号/时间戳(显式 stamp,每次 CAS 校验) | 引用计数(内存不被释放就不会被复用) |
| **额外开销** | stamp 需要额外 4/8 字节 + CAS 双字操作 | 引用计数原子操作(inc/dec) |
| **局限性** | 只防值层面的 ABA,不防指针复用 | 性能开销大(atomic inc/dec + 内存分配),不适用于高频 CAS 路径 |
> **面试记忆点**:ABA 问题分两层面——「值 ABA」(值从 A→B→A,如 `AtomicInteger`,用版本号解决)和「指针 ABA」(指针地址被释放后重用,如 `shared_ptr` lock-free 场景,用延迟回收/引用计数解决)。Java 的 `AtomicStampedReference` 解决值 ABA,C++ 的 `shared_ptr` 通过保证内存不被释放来间接解决指针 ABA,但代价是引用计数的原子操作开销。
### 【中等】CAS 算法存在哪些问题?⭐⭐⭐⭐
CAS(Compare-And-Swap)是一种无锁并发编程技术,广泛用于 Java 的 `Atomic` 类、AQS、`ConcurrentHashMap` 等并发工具中。但它也存在一些问题和限制:
**ABA 问题**
- **问题描述**:变量值从 `A` → `B` → `A`,CAS 检查时认为没有变化,但实际上已经被修改过。
- **影响**:可能导致数据不一致(如链表操作时节点被替换但指针仍有效)。

- **解决方案**:
- 使用 **版本号/时间戳**(如 `AtomicStampedReference`)。
- 使用 `boolean` 标记(如 `AtomicMarkableReference`)。
**自旋产生的 CPU 空转**
- **问题描述**:如果 CAS 长时间失败,线程会持续自旋(`while` 循环),占用 CPU 资源。
- **影响**:高并发竞争时,可能导致 CPU 使用率飙升。
- **解决方案**:
- 限制自旋次数(如 `LongAdder` 改用分段 CAS)。
- 结合 `yield()` 或 `Thread.sleep()` 减少竞争。
**只能保证单个变量的原子性**
- **问题描述**:CAS 只能对一个变量进行原子操作,无法保证多个变量的复合操作(如 `i++` 和 `j--`)。
- **影响**:需要额外同步机制(如锁)来保证多变量一致性。
- **解决方案**:
- 使用 `synchronized` 或 `ReentrantLock`。
- 设计不可变对象(如 `String`、`BigInteger`)。
**公平性问题**
- **问题描述**:CAS 是非公平的,新线程可能比等待队列中的线程更快获取锁。
- **影响**:可能导致线程饥饿(某些线程长期得不到执行)。
- **解决方案**:
- 使用公平锁(如 `ReentrantLock(true)`)。
- 结合队列调度(如 AQS 的 CLH 队列)。
**不适用于复杂操作**
- **问题描述**:CAS 适合简单操作(如 `count++`),但不适合复杂逻辑(如数据库事务)。
- **影响**:需要拆分为多个 CAS 步骤,可能引入中间状态不一致。
- **解决方案**:
- 使用锁(如 `synchronized`)。
- 改用事务内存(如 Clojure STM)。
**平台依赖性**
- **问题描述**:CAS 依赖底层 CPU 指令(如 `CMPXCHG`),不同架构性能可能差异较大。
- **影响**:在 ARM 等弱内存模型平台可能出现意外行为。
- **解决方案**:使用 JVM 内置原子类(如 `AtomicInteger`),而非手动实现。
**总结**
| 问题 | 影响 | 解决方案 |
| -------------- | -------------- | ------------------------ |
| **ABA 问题** | 数据不一致 | `AtomicStampedReference` |
| **自旋开销** | CPU 占用高 | 限制自旋次数 / 退让策略 |
| **单变量限制** | 复合操作不安全 | 锁 / 不可变对象 |
| **公平性** | 线程饥饿 | 公平锁 / 队列调度 |
| **复杂操作** | 难以实现 | 锁 / 事务内存 |
| **平台依赖** | 跨平台兼容性差 | 使用标准库 |
CAS 在无锁编程中非常高效,但需结合场景权衡利弊。在高竞争环境下,可能需要改用锁或其他并发策略。
### 【困难】LongAdder 的原理是什么?为什么高并发下比 AtomicLong 快?⭐⭐⭐
`LongAdder` 是 JDK8 引入的高性能累加器,**在超高并发场景下性能比 `AtomicLong` 高数倍**,核心思想是**分段累加(Cell)+ 合并求和**。
**(1)AtomicLong 的瓶颈**
`AtomicLong` 基于 CAS 自旋,高并发下大量线程同时竞争同一个 `value` 字段:
- CAS 失败率高,自旋浪费 CPU。
- 所有线程竞争同一缓存行,**缓存一致性流量大**(MESI 协议频繁失效)。
**(2)LongAdder 的分段设计**
`LongAdder` 继承 `Striped64`,核心结构:
```java
transient volatile long base; // 基础值,无竞争时直接 CAS 累加
transient volatile Cell[] cells; // 分段数组,竞争时分散到不同 Cell
```
每个 `Cell` 封装一个 `volatile long value`,并通过 `@Contended` 注解避免伪共享(padding 填充缓存行)。
**(3)累加流程**
```mermaid
graph TD
A[调用 add] --> B{cells 是否为 null?}
B -->|是| C[CAS 累加 base]
C -->|成功| D[返回]
C -->|失败| E[初始化 cells 数组]
B -->|否| F[根据线程 hash 定位 Cell]
F --> G[CAS 累加 Cell.value]
G -->|成功| D
G -->|失败| H{是否需要扩容?}
H -->|是| I[扩容 cells 2倍]
H -->|否| F
I --> F
```
1. 无竞争时:直接 CAS 累加 `base`(类似 AtomicLong)。
2. 有竞争时:根据线程的 hash 定位到某个 `Cell`,CAS 累加该 Cell 的 value。
3. Cell 竞争激烈时:rehash(重新计算 hash)或扩容 cells 数组(最大为 CPU 核心数的 2 的幂次)。
**(4)求和**
`sum()` 遍历所有 Cell 并加上 base,**非原子操作**,适合统计场景而非精确计数。
```java
public long sum() {
Cell[] as = cells;
long sum = base;
if (as != null) {
for (Cell a : as) {
if (a != null) sum += a.value;
}
}
return sum;
}
```
**(5)性能对比**
| 并发度 | AtomicLong | LongAdder | 提升倍数 |
| ------------- | ---------- | --------- | -------- |
| 低(1 线程) | 基准 | 略慢 | 0.9x |
| 中(8 线程) | 基准 | 2-3x | 2-3x |
| 高(64 线程) | 基准 | 5-10x | 5-10x |
**(6)选型建议**
- **需要精确值**(如序列号生成)→ `AtomicLong`
- **统计场景**(如 QPS、PV 计数)→ `LongAdder`
- **自定义累加规则**(如求最大值)→ `LongAccumulator`
### 【中等】Java 中支持哪些原子类?⭐⭐
Java 原子类底层基于 **CAS 指令(CPU 级原子操作)+ 自旋重试** 实现无锁原子操作。部分高性能原子类(如 LongAdder)采用**分段累加**优化高并发性能。
原子类相当于一种泛化的 volatile 变量,能够支持原子的、有条件的读/改/写操作。
::: info 原子类分类
:::
| 分类 | 核心类 | 作用 |
| :----------------- | :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------ |
| **基本类型原子类** | AtomicInteger、AtomicLong、AtomicBoolean | 对 `int` / `long` / `boolean` 做原子增删改查,替代加锁 |
| **引用类型原子类** | AtomicReference、AtomicStampedReference、AtomicMarkableReference | 对引用类型做原子操作,解决 CAS 的 ABA 问题 |
| **数组类型原子类** | AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray | 对数组元素做原子操作(数组本身不原子,元素原子) |
| **字段更新器** | AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater | 对字段做原子更新,无需改字段类型 |
| **累加器** | LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator | 高并发下替代 AtomicLong/Double,分段累加提性能
适合统计,但不保证实时精确值 |
::: info 核心原子类
:::
**(1)基础类**
| 类 | 核心特性 | 典型场景 |
| :-------------: | :-------------------------------------------: | :----------------: |
| AtomicInteger | 支持 getAndIncrement(i++)、compareAndSet 等 | 计数器、序列号生成 |
| AtomicBoolean | 原子更新布尔值,底层用 int 存储(0/1) | 状态标记(如开关) |
| AtomicReference | 原子更新对象引用 | 原子替换对象实例 |
**(2)解决 ABA 问题**
| 类 | 核心特性 | 记忆点 |
| :---------------------: | :------------------------------------: | :-------------------------------: |
| AtomicStampedReference | 加版本号(戳),CAS 时校验值 + 版本 | 彻底解决 ABA(版本唯一) |
| AtomicMarkableReference | 加标记位(boolean),CAS 校验值 + 标记 | 简化版 ABA 解决(仅标记是否修改) |
**(3)高性能累加器**
| 类 | 核心特性 | 记忆点 |
| :-------------: | :-------------------------------------------------------: | :-----------------------------: |
| LongAdder | 分段累加(base+cells 数组),低竞争用 base,高竞争分 cell | 高并发计数性能≈10 倍 AtomicLong |
| LongAccumulator | 自定义累加规则(如乘法、最大值),比 LongAdder 更灵活 | 支持非加减的原子计算 |
**(4)灵活类**
| 类 | 核心特性 | 记忆点 |
| :-----------------------: | :-----------------------------------: | :--------------------------: |
| AtomicIntegerFieldUpdater | 需通过静态方法创建,字段必须 volatile | 不改原有类结构,原子更新字段 |
| AtomicReferenceArray | 索引操作原子,数组长度不可变 | 原子更新数组元素 |
::: info 原子类选型
:::
- **低并发计数(如普通计数器)**→ 基本类型原子类(AtomicInteger 等)
- **高并发计数(如接口 QPS 统计)**→ 累加器(LongAdder 等)
- **操作引用对象 + 防 ABA** → 引用类型原子类(AtomicStampedReference 等)
- **操作对象的普通字段** → 字段更新器(AtomicIntegerFieldUpdater 等)
- **操作数组元素** → 数组类型原子类(AtomicIntegerArray 等)
### 【中等】什么是 ThreadLocal?⭐⭐⭐⭐⭐
::: info 什么是 ThreadLocal?
:::
在多线程环境下,共享变量存在并发安全问题。换个思路,如果变量非共享,而是各个线程独享,就不会有并发安全问题。这种思想有个术语叫**线程封闭**,其本质上就是避免共享。没有共享,自然也就没有并发安全问题。在 Java 中,`ThreadLocal` 正是根据这个思路而设计的。
**`ThreadLocal` 为每个线程都创建了一个本地副本**,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。
::: info ThreadLocal 有哪些应用场景?
:::
**(1)存储线程私有数据**
- **用户会话(Session)管理**:每个请求线程存储当前用户的 `Session`。
```java
private static final ThreadLocal currentUser = ThreadLocal.withInitial(() -> null);
// 设置当前用户
currentUser.set(user);
// 获取当前用户
User user = currentUser.get();
```
- **数据库连接(Connection)管理**:避免传递 `Connection` 参数。
```java
private static final ThreadLocal connectionHolder =
ThreadLocal.withInitial(() -> dataSource.getConnection());
```
**(2)避免参数透传**
**问题**:多层方法调用需要透传某个上下文参数(如 `traceId`)。
**解决**:使用 `ThreadLocal` 存储,避免方法参数传递。
```java
private static final ThreadLocal traceIdHolder = new ThreadLocal<>();
// 在入口处设置 traceId
traceIdHolder.set("req-123");
// 在任意深层方法获取
String traceId = traceIdHolder.get(); // 无需透传参数
```
**(3)线程安全的工具类**
**例如**:`SimpleDateFormat` 是线程不安全的,但可以用 `ThreadLocal` 包装:
```java
private static final ThreadLocal dateFormatHolder =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 线程安全地使用
String formattedDate = dateFormatHolder.get().format(new Date());
```
**最佳实践**
(1)**尽量用 `static final`**
```java
private static final ThreadLocal userHolder = new ThreadLocal<>();
```
避免重复创建 `ThreadLocal` 实例。
(2)**必须调用 `remove()`**
尤其在线程池场景,否则会导致内存泄漏。
(3)**推荐初始化默认值**
```java
ThreadLocal userHolder = ThreadLocal.withInitial(() -> new User());
```
(4)**避免在父子线程间传递**
`ThreadLocal` 不能自动继承,需手动处理(可用 `InheritableThreadLocal`)。
### 【中等】`ThreadLocal` 的原理是什么?⭐⭐⭐⭐⭐
ThreadLocal 是**线程本地变量**,核心作用是为每个线程创建独立的变量副本,实现线程间数据隔离。
::: info 核心结构
:::

| 角色 | 作用(记忆点) | 核心关系 |
| :------------: | :--------------------------------------: | :-----------------------------------------: |
| ThreadLocal | 对外暴露的操作入口(get/set/remove) | 作为 Key,关联线程的变量副本 |
| Thread | 线程对象,内置 `ThreadLocalMap` 成员变量 | 每个线程有专属的 ThreadLocalMap |
| ThreadLocalMap | 线程内部的哈希表(类似 HashMap) | Key=ThreadLocal(弱引用),Value = 变量副本 |
::: info 核心机制
:::
1. 弱引用解决内存泄漏(关键)
- ThreadLocalMap 的 Key 是 ThreadLocal 的**弱引用**。
- 当 ThreadLocal 无强引用时,GC 会回收 Key。
- 仅回收 Key 仍会残留 Value(强引用),需手动调用 `remove()` 清空,避免内存泄漏;
2. 线程隔离本质:变量副本存在 Thread 自身的 Map 中,而非 ThreadLocal 里,ThreadLocal 仅作为 “索引”;
3. 初始化机制:重写 `initialValue()` 可指定初始值,也可通过 `setInitialValue()` 手动初始化。
::: info 源码锚点
:::
(1)**Entry 的弱引用设计**:
```java
static class Entry extends WeakReference> {
Object value;
Entry(ThreadLocal> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}
```
(2)**散列与寻址**:`ThreadLocal.threadLocalHashCode` 由 `AtomicInteger` 按 **斐波那契散列(黄金分割数 0x61c88647)** 递增生成,让 key 在 2 的幂次数组中均匀分布;冲突时采用**线性探测**(`nextIndex` 顺移),而非 HashMap 的链地址法。
(3)**泄漏因果链(面试必背)**:
`ThreadLocal 实例失去强引用` → GC 回收弱引用 key(Entry.key 变 null)→ **value 仍是强引用**,被 `Thread → threadLocals → table[i].value` 这条引用链牢牢拽住 → 线程不死(线程池复用),value 永远无法回收 → 内存泄漏。
(4)**JDK 的三道防线**:
- `set()`:线性探测途中遇到 key==null 的 Entry 时触发 `replaceStaleEntry` 清理;
- `get()`:未命中时触发 `expungeStaleEntry` 清理;
- `remove()`:直接清理(唯一治本手段)。
- 前两道是「顺路清理」,线程池空闲线程长期不操作 Map 时完全失效,所以**必须 finally 中 remove()**。
::: info L4 深度:Go context.Context 替代 ThreadLocal 的设计哲学
:::
**隐式线程局部存储 vs 显式上下文传递:两种范式的根本分歧**
Go 语言从设计之初就**刻意不提供** goroutine-local storage(类似 `ThreadLocal` 的机制),而是通过 `context.Context` 显式传递请求级上下文。这体现了两种截然不同的并发编程哲学。
| 维度 | Java `ThreadLocal` | Go `context.Context` |
| :---------------- | :-------------------------------------------- | :-------------------------------------------------------- |
| **传递方式** | 隐式获取:任意深层方法直接 `get()`,无需参数 | 显式传递:每个函数签名需 `ctx context.Context` 参数 |
| **线程/协程模型** | 1:1 内核线程绑定,`ThreadLocal` 随线程存活 | M:N 协程调度,goroutine 频繁创建/切换,线程局部存储无意义 |
| **耦合度** | 隐式依赖全局状态(测试需 mock `ThreadLocal`) | 显式依赖参数(测试直接传 ctx) |
| **内存管理** | 线程池场景需手动 `remove()` 防泄漏 | GC 自动回收(ctx 随请求结束释放) |
| **超时/取消** | 无内置传播(需自行实现) | `context.WithTimeout`/`WithCancel` 沿调用链自动传播 |
| **可追溯性** | 隐式上下文难以从代码推断数据流 | 显式 ctx 让依赖链一目了然 |
**Go 为什么不提供 goroutine-local storage?**
1. **goroutine 的轻量性**:goroutine 创建成本极低(~2KB 栈空间),生命周期短暂,随请求结束即销毁。`ThreadLocal` 依赖线程长期存活才有意义,而 Go 中一个请求可能横跨多个 goroutine(如 `go func()` 子协程),线程局部存储无法随协程迁移。
2. **M:N 调度的复杂性**:goroutine 在 OS 线程间动态迁移(work-stealing),若存在 goroutine-local storage,调度器每次迁移都需搬运上下文,与 Go 的「轻量调度」理念冲突。
3. **显式优于隐式**(Go 的哲学口号 "Explicit is better than implicit"):`context.Context` 强制将追踪信息(trace_id、超时、取消信号)写入函数签名,代码审查时能一眼看到依赖链。Java 中 `ThreadLocal` 的隐式获取可能导致「远距离幽灵操作」——一个深层方法偷偷读取了 10 层调用前的 `traceId`,却不在任何函数签名中体现。
**Go context.Context 的实现机制**
```go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
// 典型用法:沿调用链传递 traceId
func HandleRequest(ctx context.Context, req *Request) {
ctx = context.WithValue(ctx, "traceId", uuid.New())
processOrder(ctx, req.Order)
}
func processOrder(ctx context.Context, order Order) {
traceId := ctx.Value("traceId").(string) // 显式取,但仍需参数传递
// ...
}
```
**各自的优缺点**
| | Java `ThreadLocal` | Go `context.Context` |
| :------- | :--------------------------------------------------- | :------------------------------------------------------------- |
| **优点** | 无需改函数签名,深度调用链方便;线程绑定天然隔离 | 显式依赖,可测试性强;支持超时/取消传播;无泄漏风险 |
| **缺点** | 隐式耦合,测试困难;线程池需手动清理;不支持取消传播 | 函数签名污染(每个函数都要 ctx);性能略低(值查找是链表遍历) |
**设计权衡总结**:
| 维度 | 选择 ThreadLocal | 选择 context.Context |
| :--------------- | :------------------------ | :------------------------- |
| **并发模型** | 1:1 线程模型(Java) | M:N 协程模型(Go) |
| **请求生命周期** | 长生命周期(线程池复用) | 短生命周期(请求即来即走) |
| **架构哲学** | 框架/中间件拦截,隐式注入 | 显式传参,依赖清晰 |
> **面试记忆点**:Java 的 `ThreadLocal` 本质是「用空间换方便」——把上下文绑定到 OS 线程上,隐式获取,代价是内存管理复杂度;Go 的 `context.Context` 是「用签名换清晰」——强制显式传递,代价是每个函数多一个参数。选择取决于并发模型(线程 vs 协程)和团队对「隐式魔法 vs 显式啰嗦」的偏好。当你看到 `ThreadLocal` 的内存泄漏问题时,Go 的方式天然免疫——因为 ctx 的生命周期与请求绑定,请求结束即 GC。
::: info 应用场景
:::
| 场景 | 核心用法 |
| :----------: | :------------------------------------------------------------- |
| **资源隔离** | 数据库连接、Session、用户上下文(如登录态),避免线程共享冲突 |
| **性能优化** | 替代方法传参,减少多线程下锁的使用(如 SimpleDateFormat 隔离) |
| **链路追踪** | 存储线程专属的追踪 ID(TraceID),全链路日志关联 |
### 【中等】如何解决 `ThreadLocal` 内存泄漏问题?⭐⭐⭐⭐⭐
**ThreadLocal 的内存泄漏问题源于其特殊的 "弱引用 Key + 强引用 Value" 存储结构**。
| 泄漏原因 | 核心逻辑 |
| :------- | :----------------------------------------------------------------------------------------------------------------------- |
| 核心矛盾 | ThreadLocalMap 的 Key 是弱引用(GC 回收),Value 是强引用(绑定线程),导致 Key 回收后 Value 成 “僵尸值”,随线程长期存活 |
| 高危场景 | 线程池(线程复用)+ 未手动清理 → 僵尸值累积,内存持续泄漏 |
**泄漏的完整因果链**:
```
ThreadLocal 失去强引用(如局部变量出作用域)
→ GC 回收弱引用 key(Entry.key == null)
→ value 仍被引用链 Thread → threadLocals → Entry.value 强引用
→ 线程池线程复用不死 → value 永远无法回收 → 泄漏
```
**为什么线程池场景「必现」**:普通线程执行完即销毁,`threadLocals` 随线程对象一起被 GC,泄漏窗口很短;线程池的核心线程**与 JVM 同生命周期**,一个未 remove 的 value 会随线程无限期存活,且每次任务叠加新的 value,泄漏持续累积。线上典型症状:老年代缓慢爬升、Full GC 后回落不明显,MAT 分析可见 `ThreadLocalMap$Entry` retained 大对象。

::: info ThreadLocal 内存泄露场景
:::
::: code-tabs#内存泄漏的具体场景
@tab 线程池环境未清理
```java
ExecutorService pool = Executors.newFixedThreadPool(5);
ThreadLocal tl = new ThreadLocal<>();
pool.execute(() -> {
tl.set(new BigObject()); // 存储大对象
// 业务逻辑。..
// 缺少 tl.remove()!线程复用后旧 Value 仍然存在
});
```
**后果**:线程被重复使用时,之前的 `BigObject` 实例无法被回收
@tab `remove()` 未放 finally 块
异常时 `remove()` 不执行
```java
ThreadLocal tl = new ThreadLocal<>();
tl.set("数据");
if (业务异常) {
throw new Exception(); // 跳过 remove()
}
tl.remove();
```
:::
::: info ThreadLocal 内存泄露解决方案
:::
**(1)最核心:用完必在 finally 调用 `remove()`(治标治本)**
在业务代码结束处(finally 块)调用 `threadLocal.remove()`,清空当前线程的 Value;
```java
ThreadLocal tl = new ThreadLocal<>();
try {
tl.set("业务数据");
// 业务逻辑执行
} finally {
tl.remove(); // 无论是否异常,必清理
}
```
**(2)兜底:依赖 Key 的弱引用特性(被动防护)**
`ThreadLocalMap` 会在 `set()`/`get()`/`remove()` 时,自动清理 “Key 为 null” 的 Entry(僵尸值)。
弱引用是 JDK 层面的兜底,但若长期不操作 `Map`(如线程池空闲),仍会泄漏,需配合主动 `remove`。
**(3)高危场景:线程池使用必规范(重点避坑)**
线程池线程复用,若前一个任务未清理 `ThreadLocal`,后一个任务会读取到脏数据 + 内存泄漏;
线程池任务中,ThreadLocal 必须在 finally 中 remove,或使用线程池的任务包装器统一清理。
**(4)辅助:规范初始化(减少泄漏风险)**
规范初始化可减少无效 set 操作,降低僵尸值产生概率。
- **方式 1**:重写 `initialValue()` 初始化,避免多次 set 导致旧值残留;
- **方式 2**:使用 `ThreadLocal.withInitial()`(Java 8+),初始化逻辑更清晰,减少空值操作;
::: info ThreadLocal 使用避坑
:::
| 常见误区 | 正确做法 |
| :----------------------------: | :----------------------------------------------: |
| 依赖 GC 自动回收 | GC 仅回收 Key,无法回收 Value,必须手动 remove |
| 线程池只初始化一次 ThreadLocal | 每次任务执行完都要 remove,而非仅初始化 |
| 认为 ThreadLocal 静态化会泄漏 | 静态化本身不泄漏,泄漏根源是未 remove + 线程复用 |
### 【中等】虚拟线程环境下 ThreadLocal 有什么问题?ScopedValue 如何解决?⭐⭐⭐
**核心结论**:虚拟线程把线程数量从数千放大到百万级,ThreadLocal 的三大旧疾被同时放大——**内存开销**(百万线程 × 每线程副本 = 内存爆炸)、**线程池污染**(`Executors.newVirtualThreadPerTaskExecutor()` 每任务新线程,Inherited 语义失效)、**不可变上下文传递**(TraceID 这类「传下去不许改」的场景,ThreadLocal 全靠约定)。JDK 21 引入的 **ScopedValue(JEP 446,JDK 21 预览 / JDK 25 转正)** 就是为虚拟线程时代设计的替代品。
**(1)ThreadLocal 在虚拟线程下的三个致命问题**
| 问题 | 平台线程时代 | 虚拟线程时代 |
| :--------------- | :----------------------------------------------------- | :------------------------------------------------- |
| **内存占用** | 数千线程 × 副本,可接受 | 百万线程 × 副本,可能直接 OOM |
| **生命周期管理** | 线程池复用,忘记 remove 会脏读 | 每任务新线程,无脏读但创建/销毁副本的开销被放大 |
| **上下文传递** | InheritableThreadLocal 在线程池失效,靠 TTL 等第三方库 | 任务即线程,需要一等公民的「作用域内只读共享」机制 |
**(2)ScopedValue 的设计思想**
`ScopedValue` 与 ThreadLocal 的根本差异是**绑定模型**:ThreadLocal 把值绑定到**线程的整个生命周期**(可随时 set/remove,可写);ScopedValue 把值绑定到**一段代码的作用域**(作用域内可读、不可变,作用域结束自动失效,无需手动 remove):
```java
// ScopedValue(JDK 21 预览引入,JDK 22 起使用 Carrier 链式 API,JDK 25 转正)
private static final ScopedValue TRACE_ID = ScopedValue.newInstance();
void handleRequest() {
ScopedValue.where(TRACE_ID, "req-123") // 绑定值
.run(() -> { // 在作用域内执行
service(); // 任意深层调用都能读到
}); // 作用域结束自动解绑,无需 remove
}
void service() {
String id = TRACE_ID.get(); // "req-123"
}
```
**(3)与虚拟线程的协同:结构化并发下自动继承**
配合 `StructuredTaskScope` 时,**子虚拟线程自动继承父线程作用域内绑定的 ScopedValue**——这正是 ThreadLocal/InheritableThreadLocal 在线程池场景一直没能干净解决的问题:
```java
ScopedValue.where(TRACE_ID, "req-123").run(() -> {
try (var scope = new StructuredTaskScope<>()) {
var user = scope.fork(() -> loadUser()); // 子虚拟线程内 TRACE_ID 可读
var order = scope.fork(() -> loadOrder()); // 同样继承
scope.join();
}
});
```
**(4)选型对比**
| 维度 | ThreadLocal | ScopedValue |
| :----------- | :---------------------------- | :------------------------ |
| 绑定目标 | 线程整个生命周期 | 一段代码作用域 |
| 可变性 | 随时 set/remove | 作用域内只读(不可变) |
| 清理方式 | 手动 remove(易遗漏) | 作用域结束自动解绑 |
| 子线程继承 | 需 InheritableThreadLocal/TTL | 结构化并发下自动继承 |
| 虚拟线程适配 | 不推荐(内存与开销) | 官方推荐 |
| 版本 | JDK 1.2+ | JDK 21 预览 / JDK 25 转正 |
**面试回答要点**:先点明「ThreadLocal 的问题不是新 bug,而是设计模型与虚拟线程的规模不匹配」,再给出 ScopedValue 的「作用域绑定 + 不可变 + 自动解绑 + 结构化继承」四个关键词,最后落到「新项目用虚拟线程时应优先 ScopedValue,存量 ThreadLocal 代码不必强迁」。
### 【中等】InheritableThreadLocal 的实现原理是什么?⭐⭐⭐
**核心设计目标**
- **线程间值继承**:子线程自动继承父线程的 ThreadLocal 值
- **与 ThreadLocal 兼容**:继承自`ThreadLocal`,保持相同 API
**数据存储位置**
继承自`ThreadLocal`,但使用线程对象的**独立字段**;`Thread.inheritableThreadLocals`(专门存储可继承的变量)
**线程创建时的值拷贝**
- **触发时机**:当父线程创建子线程(`Thread.init()`方法)
- **拷贝逻辑**:
```java
if (parent.inheritableThreadLocals != null) {
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
}
```
- **深拷贝保证隔离**:子线程获得父线程值的独立副本(修改互不影响)
**值传递规则**
- **仅初始化时拷贝**:子线程创建后父线程对值的修改不再影响子线程
- **浅拷贝问题**:若存储引用对象,父子线程仍共享同一对象(需开发者自行处理线程安全)
**与 ThreadLocal 的对比**
| 特性 | `InheritableThreadLocal` | `ThreadLocal` |
| ------------ | ---------------------------------- | --------------------- |
| **继承性** | 子线程自动继承父线程值 | 完全隔离 |
| **存储字段** | `Thread.inheritableThreadLocals` | `Thread.threadLocals` |
| **性能开销** | 略高(需初始化时拷贝数据) | 更低 |
| **使用场景** | 需要跨线程传递上下文(如 TraceID) | 线程私有数据 |
**使用注意事项**
- **对象共享风险**:若值是可变的引用对象,需自行保证线程安全
- **线程池陷阱**:线程池复用线程时会导致旧值残留(需手动清理)
- **性能影响**:大量线程创建时,值拷贝可能成为瓶颈
**典型应用场景**
```java
// 父线程设置值
InheritableThreadLocal itl = new InheritableThreadLocal<>();
itl.set("parent_value");
new Thread(() -> {
// 子线程自动读取到父线程设置的值
System.out.println(itl.get()); // 输出:parent_value
}).start();
```
**实现局限**
- **不支持动态更新**:子线程启动后父线程的修改不可见
- **无回调机制**:无法像`ThreadLocal`的`initialValue()`那样自定义子线程初始值
### 【中等】TransmittableThreadLocal 是什么?解决了什么问题?⭐⭐⭐
**`TransmittableThreadLocal`(TTL)** 是阿里巴巴开源的 [transmittable-thread-local](https://github.com/alibaba/transmittable-thread-local) 项目,**解决线程池场景下 ThreadLocal 值传递的问题**。
**(1)问题背景**
| 场景 | `ThreadLocal` | `InheritableThreadLocal` | TTL |
| ------------------ | ------------- | ------------------------ | --- |
| 子线程继承父线程值 | ❌ | ✔️ | ✔️ |
| 线程池复用线程传递 | ❌ | ❌(仅创建时拷贝一次) | ✔️ |
| 提交任务时动态传递 | ❌ | ❌ | ✔️ |
`InheritableThreadLocal` 只在**线程创建时**拷贝父线程的值,线程池复用线程时,后续提交的任务无法获取提交者的上下文。
**(2)TTL 的核心原理**
TTL 通过 **装饰 Runnable + 任务提交时快照** 实现值传递:
1. **提交任务时**:通过 `TtlRunnable.get(runnable)` 包装任务,捕获当前线程的所有 TTL 值快照。
2. **任务执行前**:在执行线程中,将快照值设置到执行线程的 TTL 中(backup 旧值)。
3. **任务执行后**:恢复执行线程的旧值,避免污染线程池中的其他任务。
```java
// 使用示例
TransmittableThreadLocal context = new TransmittableThreadLocal<>();
context.set("parent-value");
ExecutorService executor = Executors.newFixedThreadPool(2);
// 关键:用 TtlRunnable 包装,或使用 TtlExecutors.getTtlExecutorService(executor)
executor.submit(TtlRunnable.get(() -> {
System.out.println(context.get()); // 输出 parent-value
}));
```
**(3)典型应用场景**
- **链路追踪**:TraceID 跨线程池传递。
- **用户上下文**:登录态、租户 ID 在异步任务中传递。
- **日志 MDC**:MDC 上下文跨线程传递。
**(4)使用建议**
- 优先使用 `TtlExecutors.getTtlExecutorService(executor)` 装饰线程池,自动包装所有任务。
- TTL 值同样需要 `remove()`,避免内存泄漏。
- 对于 Java Agent 方式接入(无代码侵入),适合大型项目快速接入。